package com.xrui.ftp;


import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;
import org.apache.log4j.Logger;

import java.io.*;

/**
 * A more streamlined, helpful and consistent wrapper around the raw FTP client.
 * <ul>
 * <li>Minimal API
 * <li>Standard <code>Closeable</code> resource management semantics (Eclipse usually warns if you forget)
 * <li>All error reporting via exceptions, as opposed to FTPClient's exception/retval/statuscode hybrid
 * <li>Generalized, interruptible retry
 * </ul>
 * Sample of usage:
 * <pre>
 * FtpHelper ftp = INJECTOR.getInstance(FtpHelper.Factory.class).create(config);
 * try {
 *     for (String name : ftp.listNames()) {
 *         processByteArray(ftp.retrieveFile(name));
 *     }
 * }
 * finally {
 *     ftp.close();
 * }
 * </pre>
 */
public interface FtpHelper extends Closeable {

    /**
     * Returns a list of the files in the current remote working directory using the FTP
     * NLST command, opening/configuring the connection and/or automatically retrying as needed.
     * <p>
     * Any calls to this method should be inside a <code>try</code> block with a <code>finally</code>
     * calling <code>close()</code>
     *
     * @return An array of filenames. May be empty, but will never be null.
     * @throws IOException If the connection or NLST failed
     * @throws InterruptedException If the thread was interrupted during a retry wait
     */
    String[] listNames() throws IOException, InterruptedException;

    /**
     * Retrieve the contents of a single file from the server, opening/configuring the connection and/or
     * automatically retrying as needed. The contents are stored in a byte array, so this method isn't
     * suitable for very large files.
     * <p>
     * Any calls to this method should be inside a <code>try</code> block with a <code>finally</code>
     * calling <code>close()</code>
     *
     * @param remoteFilename The file to retrieve
     * @return The file content as an array of bytes. May be empty, but will never be null.
     * @throws IOException If the connection or download failed
     * @throws InterruptedException If the thread was interrupted during a retry wait
     */
    byte[] retrieveFile(String remoteFilename) throws IOException, InterruptedException;

    /**
     * Retrieve the contents of a single file from the server and save it to a local file,
     * opening/configuring the connection and/or automatically retrying as needed. Any
     * existing file matching localFilename is overwritten, even if the download fails.
     * <p>
     * Any calls to this method should be inside a <code>try</code> block with a <code>finally</code>
     * calling <code>close()</code>
     *
     * @param remoteFilename The file to retrieve
     * @param localFilename The file to save to
     * @throws IOException If the connection or download failed
     * @throws InterruptedException If the thread was interrupted during a retry wait
     */
    void saveFile(String remoteFilename, String localFilename) throws IOException,InterruptedException;

    /**
     * Close the connection. This method should never throw; logout/disconnect failures are suppressed.
     *
     * Redundant calls to this method (e.g. when no operation has been invoked to open the connection,
     * or when <code>close()</code> has already been called) are harmless.
     */
    @Override
    void close();

    /**
     * Factory interface, intended to be implemented by Guice in FtpModule.
     */
    interface Factory {

        /**
         * Obtaining an instance from this method does not open a connection, and so does not need
         * to be protected by a <code>finally</code> block calling <code>close()</code>.
         */
        FtpHelper create(FtpConfiguration config);
    }

    class Implementation implements FtpHelper {

        private static final Logger LOGGER = Logger.getLogger(FtpHelper.class);
        private final FtpClient client;
        private final FtpConfiguration config;
        private boolean isOpen = false; // "Open" here means connected AND logged in AND configured


        Implementation(FtpClient client,   FtpConfiguration config) {
            this.client = client;
            this.config = config;
        }

        @Override
        public String[] listNames() throws IOException, InterruptedException {
            ListOperation op = new ListOperation();
            executeWithRetries(op, "listing remote filenames");
            return op.names;
        }

        @Override
        public byte[] retrieveFile(String remoteFilename) throws IOException, InterruptedException {
            DownloadOperation op = new DownloadOperation(remoteFilename);
            executeWithRetries(op, "downloading " + remoteFilename);
            return op.bytes;
        }

        @Override
        public void saveFile(String remoteFilename, String localFilename) throws IOException, InterruptedException {
            DownloadAndStoreOperation op = new DownloadAndStoreOperation(remoteFilename, localFilename);
            executeWithRetries(op, String.format("downloading %s to %s", remoteFilename, localFilename));
        }


        @Override
        public void close() {
            isOpen = false;
            LOGGER.debug("FtpHelper: closing connection");
            if (client.isConnected()) {
                try { client.logout(); } catch (IOException ignored) {} // not all servers even support this
                try { client.disconnect(); } catch (IOException e) {
                    LOGGER.warn(String.format("FTPClient.disconnect threw %s : %s", e.getClass().getName(), e.getMessage()));
                }
            }
        }

        /**
         * This really wants to be a lambda but can't because Java 7. Abstracts retryable
         * operations; the key requirement is that execute() either succeeds or throws.
         */
        private interface Operation {
            void execute() throws IOException;
        }

        private class ListOperation implements Operation {
            private String[] names = null;

            @Override
            public void execute() throws IOException {
                names = client.listNames();
                if (names == null) { // error condition; successful reply returns empty array if no files
                    throw errorForLastAction("FTPClient.listNames returned null");
                }
            }
        }

        private class DownloadOperation implements Operation {
            private final String remoteFilename;
            private byte[] bytes;

            DownloadOperation(String remoteFilename) {
                this.remoteFilename = remoteFilename;
            }

            @Override
            public void execute() throws IOException {
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                if (!client.retrieveFile(remoteFilename, outputStream)) {
                    throw errorForLastAction("FTPClient.retrieveFile returned false");
                }
                bytes = outputStream.toByteArray();
            }
        }

        private class DownloadAndStoreOperation implements Operation {
            private final String remoteFilename;
            private final String localFilename;

            DownloadAndStoreOperation(String remoteFilename, String localFilename) {
                this.remoteFilename = remoteFilename;
                this.localFilename = localFilename;
            }

            @Override
            public void execute() throws IOException {
                try (OutputStream outputStream = new BufferedOutputStream(new FileOutputStream(localFilename))) {
                    if (!client.retrieveFile(remoteFilename, outputStream)) {
                        throw errorForLastAction("FTPClient.retrieveFile returned false");
                    }
                }
            }
        }

        private void executeWithRetries(Operation op, String doing) throws IOException, InterruptedException {
            int attemptsRemaining = 1 + config.maxRetries;
            while (attemptsRemaining-- > 0) {
                try {
                    ensureOpen();
                    op.execute();
                    return;
                }
                catch (IOException e) {
                    close(); // get to a known state for the next attempt, as opposed to e.g. connected but not logged in
                    LOGGER.warn(String.format("FtpHelper: %s %s", e.getClass().getName(), e.getMessage()));
                }

                if (attemptsRemaining > 0) {
                    doRetryWait(doing);
                }
                else {
                    throw new IOException(String.format("FtpHelper: giving up on %s after %d retries", doing, config.maxRetries));
                }
            }
        }

        private void ensureOpen() throws IOException {
            if (isOpen) return;

            LOGGER.debug("FtpHelper: opening and configuring connection");

            client.connect(config.host);
            if (!FTPReply.isPositiveCompletion(client.getReplyCode())) {
                throw errorForLastAction("FTPClient.connect failed");
            }

            if (!client.login(config.username, config.password)) {
                throw errorForLastAction("FTPClient.login failed");
            }

            if (config.useActive) { client.enterLocalActiveMode(); } else { client.enterLocalPassiveMode(); }
            if (!client.setFileType(FTPClient.BINARY_FILE_TYPE)) {
                throw errorForLastAction("FTPClient.setFileType failed");
            }
            if (!client.changeWorkingDirectory(config.remoteDir)) {
                throw errorForLastAction("FTPClient.changeWorkingDirectory failed");
            }

            isOpen = true;
        }

        private void doRetryWait(String doing) throws InterruptedException {
            LOGGER.info(String.format("FtpHelper: will retry %s in %d milliseconds", doing, config.retryWaitMilliseconds));
            try {
                Thread.sleep(config.retryWaitMilliseconds);
            }
            catch (InterruptedException ie) {
                LOGGER.info("FtpHelper: retry sleep interrupted while " + doing);
                throw ie;
            }
        }

        private IOException errorForLastAction(String message) {
            return new IOException(String.format("FtpHelper: %s (%s)", message, client.getReplyString().trim()));
        }
    }
}
